Skip to content

Add MCP tools for snapshot export, import, update and device target - #8497

Merged
andypalmi merged 8 commits into
mainfrom
7688-mcp-snapshot-write-tools
Sep 21, 2026
Merged

andypalmi merged 8 commits into
mainfrom
7688-mcp-snapshot-write-tools

Conversation

@cstns

@cstns cstns commented Sep 14, 2026 •

Copy link
Copy Markdown
Contributor

Closes #7688

Adds four snapshot write tools:

  • platform_update_snapshot wraps PUT /api/v1/snapshots/:id. The controller does proper partial updates, so the tool only sends the fields it was given. A blank name is not cleanly rejected: the controller throws a sequelize ValidationError and nothing maps that to a status code, so the route answers 500. The tool catches it first (whitespace only counts as blank, since the controller trims) and returns a 400. An update with no fields at all gets a 200 back with the snapshot unchanged, which reads as a successful edit that never happened, so the tool rejects that too.
  • platform_export_snapshot wraps POST /api/v1/snapshots/:id/export. credentialSecret is unconditionally required by the route (400 without it), and the description warns that the default export includes hidden env var values, plus a reminder that the same secret is needed at import time.
  • platform_import_snapshot wraps POST /api/v1/snapshots/import. One real quirk surfaced while reading the controller: uploadSnapshot calls Object.keys(snapshot.settings.env) unguarded, so a snapshot without settings.env 500s. The handler normalises an omitted env to {} so agents don't hit that. Same story for hidden env values, which get decrypted before the component filtering runs, so a keys-only or env-excluded import would 500 without a secret it does not actually need; the tool reduces env up front in both cases and the result is identical to what the route produces on its happy path. One rough edge left: the export response carries six extra fields (id, createdAt, updatedAt, ownerType, user, exportedBy) that this tool's snapshot argument does not accept, so pasting an export straight back in fails validation with unrecognized key. The description spells out which four fields to copy, but it might be worth letting the schema ignore the extras so the obvious export then import flow just works.
  • platform_set_instance_device_target wraps POST /api/v1/projects/:id/devices/settings. The description carries a caution that setting the target deploys immediately to every assigned device, and notes the route can only set a target, not clear one. The tool makes snapshotId required because the route's only reply.send sits inside the if (request.body.targetSnapshot) block: omit it and you get a 200 with an empty body and nothing changed, a silent no-op rather than an error. It is also annotated destructiveHint: true, since it overwrites what every assigned device is running rather than adding to it, which puts it behind destructive tool access instead of plain write.

Descriptions were written from the actual route/controller behavior rather than assumptions, including the owner resolution (instance or device) happening from the snapshot itself. The behaviors above were checked against a running platform, calling each route directly and invoking the matching tool with the same arguments.

@cstns cstns self-assigned this Sep 14, 2026
@codecov

codecov Bot commented Sep 14, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 77.13%. Comparing base (8c3cc3f) to head (9bbd6e0).

Additional details and impacted files
@@            Coverage Diff             @@
##             main    #8497      +/-   ##
==========================================
+ Coverage   77.07%   77.13%   +0.05%     
==========================================
  Files         466      466              
  Lines       24943    25004      +61     
  Branches     6643     6664      +21     
==========================================
+ Hits        19226    19287      +61     
  Misses       5717     5717              
Flag Coverage Δ
backend 77.13% <100.00%> (+0.05%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@andypalmi andypalmi left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All four tools line up with the routes and controllers, and the tricky bits are handled well: the partial update with the empty-name guard, the settings.env normalization that dodges the unguarded Object.keys in uploadSnapshot, and making the device target snapshot required so the route (which never replies when it is missing) can't hang. Tests cover them nicely.

One optional cleanup, same theme as the pipeline stage tools: platform_export_snapshot and platform_import_snapshot carry near-identical components schemas. The differences are just the direction wording and the "exposes hidden values" caution, and that caution already lives in the export tool's description, so repeating it in the arg is the kind of duplication worth dropping. Could this be a single shared schema in schemas.js (next to snapshotId/hostedInstanceId), spread into both?

// schemas.js
const snapshotComponents = z.object({
    flows: z.boolean().optional().describe('Include flows (default true). Excluding flows also excludes credentials'),
    credentials: z.boolean().optional().describe('Include the flow credentials (default true)'),
    envVars: z.union([z.literal('all'), z.literal('keys'), z.literal(false)]).optional().describe('Environment variables: "all" keeps keys and values (default), "keys" keeps only the names, false removes them')
}).optional()

And a smaller one: a couple of descriptions restate mechanics that already live in the args, for example the non-empty name and empty-string-to-clear notes on platform_update_snapshot. Could those stay only in the arg .describe() that owns them, keeping the description tool-level? The owner-resolution, partial-update, and immediate-deploy caution are genuinely tool-level and read well where they are.

None of this is blocking, happy for it to be a follow-up if you would rather keep this PR focused.

…s, enforce upfront validation for encrypted data, and handle excluded components properly. Add tests for various encrypted scenarios.
@cstns

cstns commented Sep 16, 2026

Copy link
Copy Markdown
Contributor Author

Pushed a follow-up to the import tool after a closer read of the controller.

Hidden env vars are also exported encrypted (the env entry gets a $), and uploadSnapshot decrypts them before the flow-credentials guard, iterating the original snapshot rather than the component-filtered copy. Two consequences the tool didn't account for:

  • Importing a snapshot with hidden env vars and no credentialSecret blows up with a raw 500, even with components.envVars: false, because the decrypt runs before the filtering and the catch regex doesn't match the crypto error.
  • A wrong secret only produces the documented 400 when flow credentials are present. Without them there's nothing to validate against (env decryption is unauthenticated aes-256-ctr), so hidden values import as silent garbage with a 200.

Since both live in the route/controller, the tool handles them client-side for now:

  • Env vars are stripped up front when envVars: false. The route ends up emptying them anyway, it just does the decrypt first, so this is the same request without the 500.
  • A snapshot carrying encrypted material (hidden env $, or flows.credentials.$ when credentials aren't excluded) with no credentialSecret is rejected with a clear 400 before the route is called.
  • The description now says when the secret is genuinely needed, and warns that a wrong one can't be detected without flow credentials.

Tests cover the strip, both rejection paths, and the two cases that should still go through.

Guarding the decrypt loop in uploadSnapshot itself (and running it on the filtered copy) is the real fix, might be worth a separate issue.

Mark platform_set_instance_device_target destructive: it overwrites the
target on every device assigned to the instance, so it belongs behind
destructive tool access rather than plain write.

Guard platform_update_snapshot: tool input is not validated platform-side,
so a blank name reached the controller and surfaced as a 500, and an update
with no fields got a 200 with the snapshot unchanged.

Reduce env vars to their names up front on a keys-only import. The route
discards the values anyway, but decrypts the hidden ones first, which forced
a credentialSecret the caller does not need.

Share one components schema between export and import, use the toolError
helper for the tool's own 400, and keep arg-level mechanics in the arg
descriptions rather than repeating them in the tool description.
Accept an export verbatim: the snapshot argument is loose now, so the six
fields an export carries on top of name/description/flows/settings no
longer fail validation. Only the four the route reads are forwarded.

Reject unencrypted flow credentials, which the route stores in the clear
(#8569), and stop asking for a credential secret when components.flows is
false, where the secret is never used (#8570).

Give settings.env a declared shape so a null value cannot reach the route,
which reads every value's properties unguarded (#8568).

Document the FF_ env var stripping, and the credentials block every export
carries whatever the snapshot holds (#8571), on both tool descriptions.
@cstns

cstns commented Sep 21, 2026

Copy link
Copy Markdown
Contributor Author

Raised the endpoint-side findings from testing these tools as separate issues, since the tools here only work around them rather than fix them:

Also #8572 for the gateway dropping the status, code and isError off tool results. Relevant here because it's what makes the careful 400s in these tools indistinguishable from a 500 on the agent's side.

The line claiming an export always carries a credentials block was only
half right: with components credentials:false or flows:false the export
carries an empty object and the import needs no secret at all. Say which
case is which.

A blank credentialSecret now fails in the tool rather than costing a round
trip to the route, which reads it as absent and answers 400. Matches what
the update tool already does for a blank name.

Note that envVars:"keys" drops the hidden flag, so a secret variable comes
back as an ordinary empty one. That lives on the shared components schema,
so it covers the import tool too.

Also carry over the payload-size caution from platform_get_snapshot_full,
since an export is always a superset of it.
@cstns

cstns commented Sep 21, 2026

Copy link
Copy Markdown
Contributor Author

Two more from testing platform_export_snapshot, neither fixable from this PR:

  • Exporting a snapshot needs a lower role than renaming one #8574 snapshot:export only needs Member, while snapshot:edit, snapshot:delete and snapshot:import all need Owner. Export is the one that hands back every flow credential and every hidden env value in a form the caller can decrypt, so the ordering looks worth a second opinion. Possibly deliberate.
  • MCP gateway doesn't enforce all of a tool's published input schema #8575 the gateway doesn't enforce the whole published input schema. components: {envVars: true} is illegal per the schema but gets through and comes back from the route as a bare Bad Request naming no field, while an unknown key is caught cleanly. Means a tool can't lean on its own schema for validation.

The tool-side findings from the same pass are fixed in 4a587ea:

  • the line claiming an export always carries a credentials block was only half right, with credentials: false or flows: false it carries {} and the import needs no secret at all
  • a blank credentialSecret now fails in the tool instead of costing a round trip to the route, matching what the update tool already does for a blank name
  • envVars: "keys" drops the hidden flag, so a secret variable comes back as an ordinary empty one. Noted on the shared components schema, so it covers the import tool too
  • carried over the payload-size caution from platform_get_snapshot_full, since an export is always a superset of it

Everything else in the export tool checked out: the unconditional credentialSecret, every components combination, the re-encryption round-trip for both credentials and hidden env values, owner resolution for device-owned snapshots, and no leak of the snapshot's own stored secret.

The name column is 255 wide, so a longer value comes back as a 500 carrying
the raw database error (#8576). The tool already shields a blank name for
the same reason, this is the other end of the same check.

Only bites on postgres: sqlite ignores the declared width, so the guard is
in the handler as well as the schema rather than relying on a test that
would pass on the default dev database either way.
@cstns

cstns commented Sep 21, 2026

Copy link
Copy Markdown
Contributor Author

platform_update_snapshot pass. Both claims in the PR description check out against the raw route: a blank name really does 500 (Snapshot name is required, the unmapped sequelize ValidationError), whitespace-only counts as blank because the controller trims before validating, and an empty body really does answer 200 with the snapshot untouched. Partial updates, clearing a description with "", owner resolution for device-owned snapshots and the audit diffs all behave as described.

One new thing, fixed in 2666b0a: a name over 255 characters was going through to a 500 carrying the raw database error, since the column is DataTypes.STRING. The tool already shielded the blank end of that check, so this is just the other end. Raised the endpoint side as #8576.

Only bites on postgres, sqlite ignores the declared width, so the guard sits in the handler as well as the schema rather than leaning on a test that would pass on the default dev database regardless.

Also corrected #8575 while I was there. I'd guessed the gateway might be skipping other schema keywords too, but minLength turns out to be enforced properly (name: "" here is caught with a precise message), so the gap really does look specific to const.

…t radius

"This tool can only set a target, not clear one" read as a limitation of the
tool, so an agent would go looking for another way. There isn't one: the
route ignores a null target and answers 200 without changing anything. The
only way to remove a target is to delete the snapshot it points at, which
clears it from the instance and from every assigned device.

The description also tells the caller to confirm before deploying to every
assigned device, without giving them anything to confirm against. Point at
platform_list_remote_instances scoped by hostedInstanceId, which answers
exactly which devices a call will hit.
@cstns

cstns commented Sep 21, 2026

Copy link
Copy Markdown
Contributor Author

platform_set_instance_device_target pass, which completes all four tools in this PR.

Tested against a throwaway application and instance with two devices assigned to it, so the deploy path ran for real. Everything in the description checks out:

  • the destructive classification is wired correctly, invoke_write_tool refuses it with is a delete-class tool; call invoke_delete_tool instead
  • the CAUTION is accurate rather than defensive. One call and both assigned devices carried the new targetSnapshot; switching to another snapshot propagated to both again
  • the silent no-op is real. The route with {} answers 200 with a zero-length body and changes nothing, so making snapshotId required is the right call
  • snapshot ownership is enforced (Invalid snapshot for a snapshot belonging to another instance, and for one that doesn't exist), an unknown instance gives Not Found, and a non-UUID instance id is caught at the gateway

Two description fixes in 9bbd6e0:

  • "This tool can only set a target, not clear one" read as a limitation of the tool, so an agent would go hunting for another way to clear it. There isn't one. The route ignores {"targetSnapshot": null} and answers 200 without changing anything. The only thing that actually clears a target is deleting the snapshot it points at, which I confirmed drops it from the instance and from every assigned device. Worth spelling out, since "delete the snapshot" is a surprising answer to "how do I undo this"
  • the description told the caller to confirm before deploying to every assigned device but gave them nothing to confirm against. Setting a target on an instance with no devices returns the same {"status": "okay"} as on one with fifty. Now points at platform_list_remote_instances scoped by hostedInstanceId, which answers exactly which devices a call will hit

Didn't raise an endpoint issue for the missing clear, it looks deliberate rather than broken. Happy to raise it as a question if anyone disagrees.

Also added a third data point to #8575: pattern is enforced by the gateway too, so const really is the odd one out rather than part of a general gap.

@cstns
cstns requested a review from andypalmi September 21, 2026 11:18
@cstns
cstns marked this pull request as ready for review September 21, 2026 11:18

@andypalmi andypalmi left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The latest commits cover everything from the last pass: shared components schema, the blank-secret and name-length guards on both ends, and the device-target description now spelling out what it can and cannot undo. Splitting the endpoint and gateway findings into their own issues is the right call, since the tools only work around them here.

On #8574, that role ordering is pre-existing rather than something this PR introduces, so it should not hold this up. Fine to leave to triage on that issue and change the behaviour later if we decide to.

Approving.

@andypalmi
andypalmi enabled auto-merge (squash) September 21, 2026 12:42
@andypalmi
andypalmi merged commit 0f075c2 into main Sep 21, 2026
36 of 38 checks passed

This branch was successfully deployed

1 active deployment
staging — 9bbd6e0b Deployed Sep 21, 2026 by andypalmi via Remove application #11758
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5.2-b Write tools, non-destructive (phase 2)

2 participants